fix: treat empty on-disk JSON state files as empty state#616
Conversation
… error
Truncated writes to the CLI's JSON state files (credentials.json,
~/.slack/config.json, project .slack/config.json, .slack/apps.json,
.slack/apps.dev.json) can leave a file at zero bytes on disk when a prior
process is interrupted between afero.WriteFile's O_TRUNC and the actual
write. Once that happens, every subsequent CLI invocation reads the same
0-byte file, hits "unexpected end of JSON input" in the read helper, and
raises ErrUnableToParseJSON.
The June 2026 monthly report showed 1,317,610 unable_to_parse_json events
from just 38 users (a hot loop concentrated in a small cohort running
`platform run` and `api` in tight automation loops), which matches this
failure mode: a file that never repairs itself keeps re-triggering on
every invocation.
Add a shared goutils.IsEmptyJSON helper and use it in the five hot
readers (auth credentials, system config, project config, deployed apps,
local dev apps). When the file exists but is empty or whitespace-only,
return the same zero-value state the "file does not exist" branch
already returns — the app_client readers also re-serialise a valid `{}`
so the file self-heals on the next successful write. Genuinely malformed
JSON still returns ErrUnableToParseJSON as before.
Test coverage: added unit tests covering zero-byte and whitespace-only
inputs for each of the five readers, plus a unit test for the
IsEmptyJSON helper itself. Existing "broken JSON" tests are unchanged.
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #616 +/- ##
==========================================
+ Coverage 71.73% 71.79% +0.06%
==========================================
Files 227 227
Lines 19261 19283 +22
==========================================
+ Hits 13817 13845 +28
+ Misses 4228 4224 -4
+ Partials 1216 1214 -2 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
mwbrooks
left a comment
There was a problem hiding this comment.
Comments for the kind reviewers
| // every subsequent CLI invocation reads the same 0-byte file and logs | ||
| // ErrUnableToParseJSON in a hot loop. | ||
| if goutils.IsEmptyJSON(configFileBytes) { | ||
| config.Surveys = map[string]SurveyConfig{} |
There was a problem hiding this comment.
note: I thought there might be an initialize function, but this same pattern is used elsewhere.
zimeg
left a comment
There was a problem hiding this comment.
@mwbrooks LGTM! I think this is alright to merge with plans to follow up with "atomic" writes. I emphasize this because guarding against these empty error cases isn't as ideal as preventing such. The pattern you suggest is fascinating to me 🧠 💡
I'm thinking we should merge this for next release after finding testing performs as expected!
| // Treat an empty (or whitespace-only) file as "no apps saved yet" rather | ||
| // than a parse error. This state occurs when a prior write was truncated | ||
| // but not completed (e.g. a process was interrupted between afero.WriteFile's | ||
| // O_TRUNC and the actual write). Without this guard, every subsequent CLI | ||
| // invocation logs ErrUnableToParseJSON on the same file. |
There was a problem hiding this comment.
🔭 thought: The follow up "atomic" writes is a clever and good workaround to have! I'm worried this patch might cause existing project apps to seem missing instead of a JSON error appearing, but the root cause is unrelated to this PR.
| // Treat an empty (or whitespace-only) credentials file as "no credentials | ||
| // stored" rather than a parse error. This state occurs when a prior write | ||
| // was truncated but not completed (e.g. a process was interrupted between | ||
| // afero.WriteFile's O_TRUNC and the actual write). Without this guard, | ||
| // every subsequent CLI invocation reads the same 0-byte file and logs | ||
| // ErrUnableToParseJSON in a hot loop. | ||
| if goutils.IsEmptyJSON(raw) { | ||
| return types.AuthByTeamID{}, nil | ||
| } | ||
|
|
||
| err = json.Unmarshal(raw, &auths) |
There was a problem hiding this comment.
👾 thought: Perhaps covered in the "atomic" reads and writes once more, but I find these functions pair well together and am curious if our own JSON package might handle blank files instead of addressing this at each callsite?
| func IsEmptyJSON(data []byte) bool { | ||
| return len(bytes.TrimSpace(data)) == 0 | ||
| } |
There was a problem hiding this comment.
🪬 quibble: Calling this "empty JSON" to me is confusing because I'd expect:
{}And instead might prefer to call this IsBlankFile or something similar but of course the description above is amazing at making this clear.
srtaalej
left a comment
There was a problem hiding this comment.
tested and it looks great!
Changelog
Summary
This pull request helps to address
unable_to_parse_jsoninternal errors that some users are experiencing.Problem
afero.WriteFileopens withO_TRUNCbefore writing. If the process is interrupted (SIGKILL, host crash, oom, user cancel) after the truncate but before the write completes, the JSON state file is left at zero bytes. Every subsequent CLI invocation then reads the file, callsjson.Unmarshal(nil, …)on the empty contents, and raisesErrUnableToParseJSON. The file never self-heals: the reader errors out before the caller reaches the code path that would rewrite it.Affected state files:
~/.slack/credentials.json~/.slack/config.json.slack/config.json.slack/apps.json.slack/apps.dev.jsonWhat this PR changes
Adds a single helper
goutils.IsEmptyJSON([]byte) booland updates the five readers so that an empty (or whitespace-only) file returns the same zero-value state as the "file does not exist" branch, rather than surfacing a parse error:internal/auth/auth.go—auths()returns an emptyAuthByTeamIDinternal/config/system.go—UserConfigreturns a config withSurveysinitialisedinternal/config/project.go—ReadProjectConfigFilereturns a config withSurveysinitialisedinternal/app/app_client.go—readDeployedAppsandreadLocalAppsreturn empty maps and re-serialise a valid{}so the file self-heals on the next write cycleGenuinely malformed JSON still returns
ErrUnableToParseJSON. Existing broken-JSON tests are untouched.Test plan
Test_IsEmptyJSONcovers empty, whitespace-only,{}, and populated inputsTest_Client_authsempty-file subtest returns no auths, no errorTest_SystemConfig_UserConfigempty-file subtest returns zero-valued config, no errorTest_ReadProjectConfigFileempty-file subtest returns zero-valued config, no errorAppClient.readDeployedAppsempty-file subtest returns empty apps and writes{}back to diskAppClient.readLocalAppsempty-file subtest returns empty local apps and writes{}back to diskgo test ./internal/... ./cmd/...passesmake lintpasses with zero issuesFollow-ups (not in this PR)
afero.WriteFilewith a temp-file +Renamepattern across the five state writers so the durable fix is that these files can never reach a 0-byte state in the first place.goutils(or a newinternal/statefilepackage) so future state files pick up the guard for free.internal/api/activity.go—ErrHTTPResponseInvalidon the activity poll loop shows the same "thousands of events off one broken response" shape; worth a matching guard.internal/update/sdk.go— retry with backoff on persistently malformed SDK hook response so a broken SDK doesn't dominate the error telemetry either.